feat(ui): enrich desktop notifications (task 19)#113
Conversation
Wire the tauri-plugin-notification bridge to honour the user's `notifications_enabled` flag on every event, surface filename + size + avg speed + duration on `DownloadCompleted`, and prefix the failure reason with the filename on `DownloadFailed` (truncated to 200 chars to cap the toast and avoid leaking long URL/credential payloads). Bursts of completions are debounced through a new pure-domain `NotificationGrouper` (5 s sliding window, threshold 3): the third event in the window collapses into a single "N downloads completed" toast and any further completions in the same window are suppressed. Pure helpers `format_size` / `format_speed` / `format_duration` live under `domain/notification/format.rs` (base 1024, one decimal, zero-leading components dropped) so the formatting policy is testable without an adapter and the domain layer keeps its std-only discipline (no `humansize` dependency). The bridge call site in `lib.rs` now threads the existing `Arc<dyn ConfigStore>` and `Arc<dyn DownloadReadRepository>` so the gating + lookup share the instances the IPC layer already mutates, no double-instantiation. Click-to-open / click-to-focus actions are blocked upstream: `tauri-plugin-notification` 2.3.3 desktop API consumes the `NotificationHandle` returned by `notify_rust` internally so the click callback is unreachable. Documented in `notification_bridge.rs` and tracked for revisit when the plugin exposes `on_event`.
📝 WalkthroughWalkthroughDesktop notifications now re-check persisted Changes
Sequence Diagram(s)sequenceDiagram
participant EventBus
participant NotificationBridge
participant ConfigStore
participant DownloadReadRepository
participant NotificationGrouper
participant DesktopNotification as Desktop Notification
EventBus->>NotificationBridge: DomainEvent (DownloadCompleted / DownloadFailed)
NotificationBridge->>ConfigStore: read notifications_enabled
alt notifications_enabled == true
NotificationBridge->>NotificationGrouper: record(now_secs)
NotificationGrouper-->>NotificationBridge: Decision (ShowSingle / ShowAggregated / Suppress)
alt ShowSingle
NotificationBridge->>DownloadReadRepository: find_download_detail(id)
DownloadReadRepository-->>NotificationBridge: filename, size
NotificationBridge->>NotificationBridge: format_size / format_speed / format_duration
NotificationBridge->>DesktopNotification: show enriched notification
else ShowAggregated
NotificationBridge->>DesktopNotification: show "N downloads completed"
else Suppress
Note over NotificationBridge: suppress notification
end
else notifications_enabled == false
Note over NotificationBridge: skip notification
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~40 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Greptile SummaryImplements task 19 enriched desktop notifications: the bridge now reads Confidence Score: 4/5Safe to merge with awareness of two open discussions from the previous review round that remain unresolved in code No new P0 or P1 issues found in this pass. The previous review flagged the IEC/SI unit label mismatch (KB/MB/GB/TB with base-1024 arithmetic) and the ShowAggregated UX interaction (events 1 and 2 show individual toasts before event 3 fires an aggregated one) — both are still present but appear to be deliberate trade-offs acknowledged in the PR description and CHANGELOG. Score capped at 4 to reflect the open P1-level prior threads. src-tauri/src/domain/notification/format.rs (SI/IEC label choice), src-tauri/src/adapters/driven/notification/notification_bridge.rs (burst aggregation UX)
|
| Filename | Overview |
|---|---|
| src-tauri/src/adapters/driven/notification/notification_bridge.rs | Major rewrite: adds config gate, burst grouper, enriched bodies (filename+size), error truncation, and error logging before the notification gate; previous P1s (ShowAggregated with individual toasts, IEC labels) remain open |
| src-tauri/src/domain/notification/grouper.rs | New pure domain state machine for burst aggregation; 5 s sliding window + threshold 3 with backward-clock reset; 10 tests covering all boundary scenarios |
| src-tauri/src/domain/notification/format.rs | New pure formatting helpers; uses base-1024 arithmetic with SI unit labels (KB/MB/GB/TB instead of KiB/MiB/GiB/TiB) — label/arithmetic mismatch flagged in previous review and still present |
| src-tauri/src/domain/notification/mod.rs | New module entry point re-exporting format helpers and grouper types; clean std-only domain layer |
| src-tauri/src/lib.rs | Threads Arc and Arc to spawn_notification_bridge via .clone(); minimal, correct wiring change |
| CHANGELOG.md | Detailed changelog entry for task 19 enriched notifications; acknowledges speed/duration omission and click-action deferral |
Sequence Diagram
sequenceDiagram
participant EB as EventBus
participant NB as notification_bridge
participant CS as ConfigStore
participant RR as DownloadReadRepository
participant NG as NotificationGrouper
participant OS as OS Toast
EB->>NB: DomainEvent::DownloadFailed
NB->>NB: tracing::error! (before gate)
NB->>CS: is_notifications_enabled()
alt notifications disabled
NB-->>NB: return (toast suppressed)
else notifications enabled
NB->>RR: find_download_detail(id)
NB->>OS: "Download failed · filename · Error: ..."
end
EB->>NB: DomainEvent::DownloadCompleted
NB->>CS: is_notifications_enabled()
alt notifications disabled
NB-->>NB: return
else notifications enabled
NB->>NG: record(epoch_secs)
alt ShowSingle
NB->>RR: find_download_detail(id)
NB->>OS: "filename · size"
else ShowAggregated{count}
NB->>OS: "N downloads completed"
else Suppress
NB-->>NB: no-op
end
end
Reviews (4): Last reviewed commit: "fix(notification): drop raw error text f..." | Re-trigger Greptile
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src-tauri/src/adapters/driven/notification/notification_bridge.rs`:
- Around line 129-134: The code incorrectly derives transfer statistics from
created_at (variables: created_at, now_secs, duration) which inflates durations
for queued/paused downloads; remove or guard the avg_speed and duration
formatting (the lines that push format_speed(total / duration.max(1)) and
format_duration(duration) into parts) and instead only show those fields when a
real active-transfer metric is available (e.g., a transfer_start or
active_duration field on d) or omit them entirely until the read model exposes
them; update the logic around d.total_bytes and parts.push(...) in
notification_bridge.rs to check for an explicit transfer-start/active-duration
metric or skip adding speed/duration.
- Around line 151-157: The truncate_error function currently takes
MAX_ERROR_BODY_CHARS characters then appends the ellipsis, producing a string
one char longer than the stated cap; change the logic in truncate_error to
return the original string when error.chars().count() <= MAX_ERROR_BODY_CHARS,
otherwise collect only MAX_ERROR_BODY_CHARS - 1 characters and then append '…'
so the final length is at most MAX_ERROR_BODY_CHARS (also guard against the
edge-case where MAX_ERROR_BODY_CHARS is 0 or 1 by handling those sizes
explicitly before subtracting).
In `@src-tauri/src/domain/notification/format.rs`:
- Around line 17-23: The formatting chooses the unit before rounding, so values
like 1024*1024-1 can display as "1024.0 KB"; adjust the logic in the function(s)
(e.g., format_bytes and format_speed) to perform rounding-aware unit selection:
after computing value and idx as shown, round the value to one decimal (or
compute the rounded_value that would be displayed) and if the rounded_value >=
1024.0 then divide value by 1024.0 and increment idx (ensuring idx stays within
UNITS bounds) before formatting; this ensures boundary cases roll over to the
next unit correctly.
In `@src-tauri/src/domain/notification/grouper.rs`:
- Around line 75-81: Detect and handle wall-clock backwards jumps in record():
if the incoming now_secs is less than the deque's back (most recent) timestamp,
clear or reset the timestamps deque and the burst_aggregated flag before pushing
the new time so stale "future" entries can't corrupt the 5-second window; update
the same logic in the sibling method that mirrors 99-107 (the other notification
recording/aggregation path) to perform the same check (compare now_secs with
timestamps.back(), clear timestamps and reset burst_aggregated when now_secs <
back) and then proceed to call prune(now_secs) and push_back(now_secs).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 6a406736-917f-4a7d-951a-7c572ce12eae
📒 Files selected for processing (7)
CHANGELOG.mdsrc-tauri/src/adapters/driven/notification/notification_bridge.rssrc-tauri/src/domain/mod.rssrc-tauri/src/domain/notification/format.rssrc-tauri/src/domain/notification/grouper.rssrc-tauri/src/domain/notification/mod.rssrc-tauri/src/lib.rs
| pub fn record(&mut self, now_secs: u64) -> NotificationDecision { | ||
| self.prune(now_secs); | ||
| // Window completely drained → previous burst ends, reset flag. | ||
| if self.timestamps.is_empty() { | ||
| self.burst_aggregated = false; | ||
| } | ||
| self.timestamps.push_back(now_secs); |
There was a problem hiding this comment.
Reset burst state when wall-clock time moves backward.
record() assumes timestamps stays sorted, but the bridge feeds it wall-clock seconds. If the system clock jumps backward, older “future” entries remain in the deque and later completions can be aggregated/suppressed against the wrong 5-second window.
💡 Suggested fix
pub fn record(&mut self, now_secs: u64) -> NotificationDecision {
+ if self.timestamps.back().is_some_and(|&last| now_secs < last) {
+ self.timestamps.clear();
+ self.burst_aggregated = false;
+ }
self.prune(now_secs);
// Window completely drained → previous burst ends, reset flag.
if self.timestamps.is_empty() {
self.burst_aggregated = false;
}+ #[test]
+ fn test_backward_clock_resets_burst_state() {
+ let mut g = NotificationGrouper::new();
+ g.record(100);
+ g.record(101);
+ assert_eq!(g.record(99), NotificationDecision::ShowSingle);
+ }Also applies to: 99-107
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src-tauri/src/domain/notification/grouper.rs` around lines 75 - 81, Detect
and handle wall-clock backwards jumps in record(): if the incoming now_secs is
less than the deque's back (most recent) timestamp, clear or reset the
timestamps deque and the burst_aggregated flag before pushing the new time so
stale "future" entries can't corrupt the 5-second window; update the same logic
in the sibling method that mirrors 99-107 (the other notification
recording/aggregation path) to perform the same check (compare now_secs with
timestamps.back(), clear timestamps and reset burst_aggregated when now_secs <
back) and then proceed to call prune(now_secs) and push_back(now_secs).
There was a problem hiding this comment.
3 issues found across 7 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src-tauri/src/adapters/driven/notification/notification_bridge.rs">
<violation number="1" location="src-tauri/src/adapters/driven/notification/notification_bridge.rs:129">
P1: Don't compute transfer duration from `created_at`; it includes queue/paused time and can misreport both duration and average speed in completion notifications. Use an actual transfer-duration metric (or omit these fields until one is available).</violation>
<violation number="2" location="src-tauri/src/adapters/driven/notification/notification_bridge.rs:155">
P2: This truncation exceeds the configured 200-character cap once the ellipsis is appended; reserve one character for `…` before collecting.</violation>
</file>
<file name="src-tauri/src/domain/notification/format.rs">
<violation number="1" location="src-tauri/src/domain/notification/format.rs:23">
P3: Promote to the next unit when rounding crosses `1024.0`; values just below a boundary can currently render as `1024.0 KB/MB` instead of `1.0 MB/GB`.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
- domain/notification/format.rs: promote unit when rounding crosses
1024.0 so 1 MiB - 1 byte renders as "1.0 MB" instead of "1024.0 KB"
(cubic P3, coderabbit suggestion). New test
test_format_size_promotes_unit_when_rounding_crosses_boundary.
- domain/notification/grouper.rs: detect wall-clock backwards jumps
(NTP correction, manual time change) and clear stale "future"
timestamps + reset burst flag before pruning so corrupted windows
cannot bias subsequent decisions (coderabbit suggestion). New test
test_backwards_clock_jump_resets_window_and_returns_show_single.
- adapters/driven/notification/notification_bridge.rs: drop avg_speed
and total duration from the completion body. DownloadDetailView only
exposes created_at (queue admission), so the previous duration
inflated by queued/paused time and the derived speed reported the
wrong value (cubic P1, greptile suggestion). Body is now
"{filename} · {size}"; the omission is documented inline so the
fields can be reintroduced once the read model surfaces a real
transfer-start metric (e.g. via the HistoryEntry produced on
completion).
- truncate_error: reserve one slot for '…' before collecting so the
rendered failure body respects MAX_ERROR_BODY_CHARS exactly instead
of overflowing by one (cubic P2, coderabbit suggestion). Replaced
test_failed_body_truncates_long_error_messages with two focused
tests covering verbatim short strings and the exact-cap behaviour.
- CHANGELOG.md: rewrite the task 19 entry to match the shipped scope
(filename + size only, rounding-aware formatter, backwards-clock
guard) and explain the duration/speed deferral.
The notifications gate at the top of the EventBus subscriber returned early for every event, including the tracing::error! call for DownloadFailed. Disabling desktop notifications therefore silenced the error log too — a regression flagged by greptile P1 on PR #113. Move the error log before the gate so observability is independent of the user's toast preference; the toast itself stays gated.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src-tauri/src/adapters/driven/notification/notification_bridge.rs`:
- Around line 57-59: The match arm handling DomainEvent::DownloadFailed
currently logs the raw error (error = %error) which can leak sensitive payload;
replace that structured field with a sanitized summary instead (e.g.,
error_summary = %sanitize_error_summary(&error) or call
summarize_download_error(&error)) and implement a small helper
(sanitize_error_summary / summarize_download_error) referenced from
notification_bridge.rs that strips URLs/tokens/headers, collapses large bodies,
and limits length before returning a safe string for logging; update the
tracing::error! call in the DomainEvent::DownloadFailed arm to use that helper.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 4a623fd3-9dcf-43e1-8f89-748744af3b82
📒 Files selected for processing (1)
src-tauri/src/adapters/driven/notification/notification_bridge.rs
The structured `error = %error` field on the failure log can carry URLs, auth tokens or hoster response bodies into global tracing output, which gets persisted and aggregated. The per-download log bridge already captures the full error against the download id, so the global log only needs the id for correlation. Flagged by coderabbit on PR #113.
Summary
• Rich notification body with filename, size, speed, duration (e.g., video.mp4 · 512.3 MB · 2.5 MB/s · 3m 25s)
• Burst aggregation: ≥3 simultaneous notifications collapse to single summary within 5s window
• Domain layer: pure formatting + sliding-window state machine (std-only, zero external deps)
• Respect notifications_enabled config gate (immediate toggle)
• Error notification includes truncated error message (capped 200 chars to prevent toast overflow)
Implementation
Domain (
src-tauri/src/domain/notification/):format.rs: Three pure helpers (format_size, format_speed, format_duration) with 13 unit testsgrouper.rs: NotificationGrouper state machine with 10 tests (single/burst/window-drain/boundary scenarios)Bridge (
src-tauri/src/adapters/driven/notification/notification_bridge.rs):Wiring (
lib.rs):Notes
Summary by cubic
Enriched desktop download notifications with filename + size, burst grouping, and instant respect of the
notifications_enabledtoggle viatauri-plugin-notification. Implements Task 19; click-to-open is deferred due totauri-plugin-notificationlimits.New Features
DownloadCompleted: body shows "{filename} · {size}" (speed/duration omitted for now).DownloadFailed: includes filename and a truncated error (200 chars).format_size,format_speed,format_duration) andNotificationGrouper(std-only); bridge wired with sharedArc<dyn ConfigStore>andArc<dyn DownloadReadRepository>; readsnotifications_enabledon every event.Bug Fixes
DownloadFailedbefore the notifications gate and removes raw error text from global logs to avoid leaking credentials (full error remains in per-download logs).Written for commit 86355f2. Summary will update on new commits.
Summary by CodeRabbit